iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
自我挑戰組

React 入門到實作與除錯|30 天哩ㄟ刻系列 第 7

Day 7 : 專精一思,讓元件保持純粹

  • 分享至 

  • xImage
  •  

若還似從前那麼單純,恐怕在客戶手中早已死了幾百回了。

今天目標

  1. 認識純函式
  2. 分清楚計算畫面和修改外部資料
  3. 理解 props 為什麼要當成唯讀。
  4. 建立學習摘要元件
  5. 錯誤修正練習

今天的內容相對的多,主要是為了建立更好的觀念,未來比較不會被AI騙(?)

認識純函式

看起來很數學的名詞,可以這樣理解,一個函式在遇到相同輸入的時候,輸出結果會相同,舉個例子:

現在有個函數我們叫他 f ,f 可以輸入一個 x 計算,輸出結果為 x + 1,就變成下面這樣,只要輸入相同的 x ,輸出結果就永遠都是一樣的。

f(x) = x + 1

代入 x = 2:f(2) = 2 + 1 = 3
代入 x = 5:f(5) = 5 + 1 = 6
再代入 x = 2:f(2) = 2 + 1 = 3

純函式有兩個重點:

  • 相同輸入,相同結果。 不會因為先前執行過幾次,就算出不同答案。
  • 不修改呼叫前就存在的外部變數或物件。 只算自己的結果,不改別人的資料。

來看看實際用在程式上會長怎麼樣吧,以學習卡片的剩餘天數來看:

function getRemainingDays(day: number, totalDays: number) {
  return Math.max(totalDays - day, 0);
}

getRemainingDays(7, 30); // 23
getRemainingDays(7, 30); // 還是 23
getRemainingDays(31, 30); // 0

Math.max(..., 0) 是用來取兩個數字裡較大的,避免已經超過目標時,還顯示負的剩餘天數。

套回學習卡片的 JSX

上面清楚了,回頭看之前的 LearningCard,其實我們已經寫過純函式:

type LearningCardProps = {
  title: string;
  topic: string;
  day: number;
  totalDays?: number;
};

function LearningCard({
  title,
  topic,
  day,
  totalDays = 30,
}: LearningCardProps) {
  const remainingDays = Math.max(totalDays - day, 0);
  const isCompleted = day >= totalDays;

  return (
    <section>
      <h2>{title}</h2>
      <p>今天練習 {topic}</p>
      <p>目前進度:第 {day} 天 / 共 {totalDays} 天</p>
      <p>{isCompleted ? "已完成" : "還在努力"}</p>
      {remainingDays > 0 && <p>接下來還有 {remainingDays} 天</p>}
    </section>
  );
}

daytotalDays 是計算進度的輸入,remainingDaysisCompleted 是算出的結果,最後用 JSX 把結果放進畫面。前面數學範例回傳一個數字,這裡則回傳描述卡片內容的 JSX。

這個元件符合剛才的兩個重點:相同的 props 會得到相同的卡片內容,而且計算時只建立自己的區域變數(Local Variable),沒有修改 props 或外部資料。每次渲染都依照當次輸入重新計算,不會把上一次的進度累加進來。

重點再一次:
相同輸入,相同結果。
不修改呼叫前就存在的外部變數或物件。

分清楚計算畫面和修改外部資料

下面用範例來說明計算畫面是什麼:

回到剛才的 LearningCard,這兩行都是根據 props 計算:

const remainingDays = Math.max(totalDays - day, 0);
const isCompleted = day >= totalDays;

傳入 day={7}totalDays={30},就算出 remainingDays23isCompletedfalse。JSX 再使用這些結果顯示「還在努力」和「接下來還有 23 天」。

算完以後,收到的 day 仍然是 7,原本那筆計畫的進度也還是第七天。
這就是計算畫面:讀取輸入,建立這次要用的結果。

那麼修改外部資料呢,接著看:

以原本的三筆計畫資料為例,假如在 Welcome 函式裡加上這一行:

// 錯誤示範:在渲染時修改函式外面的既有資料。
learningPlans[0].day += 1;

這次改到的是 learningPlans 第一筆物件本身。原本的第七天會被改成第八天,再執行一次又變成第九天;其他讀取這筆資料的元件,也會受到影響。

除了回傳計算結果,還修改了外部既有資料、寫入儲存空間或觸發其他外部動作,這些額外影響稱為副作用(side effect)

現在問題來了,開發的時候怎麼知道現在這個是不是純函式,舉幾個元件內的動作來看:

元件裡的動作 判斷
daytotalDays 算剩餘天數 可以,根據輸入計算結果
filter() 算出有幾份計畫完成 可以,篩選條件只讀取資料
修改函式外的計數器,記錄元件執行幾次 不適合,結果會受到之前的渲染影響
直接改傳進來的 plan.day 不適合,會改到呼叫者提供的物件
在元件本體送出請求、寫入儲存空間或直接修改 DOM 這些會影響外部,應安排在渲染之外

判斷純粹性時,要一起檢查「相同輸入是否得到相同結果」與「計算是否造成外部影響」。與使用者操作有關的工作,通常放在事件處理函式;需要和外部系統同步時,才評估 Effect。

關於元件的純粹性,可以再看這裡:
React:Components and Hooks must be pure
React:Keeping Components Pure

元件裡完全不能修改東西嗎?

可以修改這一次呼叫才建立、沒有改到外部資料的區域變數或物件。

區域修改看這邊:React:Local mutation

範例:

function TopicList() {
  const topics = ["JSX", "props"];
  topics.push("元件的純粹性");

  return (
    <ul>
      {topics.map((topic) => <li key={topic}>{topic}</li>)}
    </ul>
  );
}

每次呼叫 TopicList 都會建立新的 topics,再加入一個主題,結果固定是三項。這三個主題不重複,這裡可以用主題文字當 key。

如果把 topics 搬到函式外面,卻把 push() 留在裡面,就會變成每次渲染都往同一個陣列加資料。方法名字一樣,改到誰的資料卻不同,那就是副作用(side effect)了。

props 為什麼要當成唯讀?

props 要當成唯讀,是因為它是外層元件提供給這次渲染的輸入。 內層元件可以根據它計算結果,但不應直接改寫收到的資料。

要改進度,就從資料來源更新

沿用卡片的寫法,上層元件用 day={plan.day} 傳入進度,LearningCard 負責讀取並顯示。希望進度從第七天變成第八天,那就是把上層元件使用的資料,day 改成 8,存檔後讓元件根據新輸入計算結果出來。

唯讀仍然可以搭配變動的畫面:只要資料來源提供新的值,內層元件就可以收到新的 props,若要透過按鈕更新進度,就再接上事件與 state。

官方的說明看這邊:React:Props and state are immutable

傳入物件或陣列,可能共用同一份資料

目前的 day={plan.day} 傳入的是數字。如果改成傳整筆物件,或像後面我們要做的摘要 plans={learningPlans} 傳入陣列,內層元件收到的仍然是同一份資料的參照(reference),React 不會自動幫它複製一份。

因此,在內層元件裡直接改物件屬性或陣列內容,也會改到外層元件持有的那份資料。即使沒有立刻報錯,也不代表符合 props 唯讀的原則。

換個名字或複製陣列,資料就獨立了嗎?

const 不是把整份資料凍起來,看範例:

{
  const originalPlans = [{ id: "my-react", day: 7 }];
  const copiedPlans = [...originalPlans];

  copiedPlans[0].day = 8;

  console.log(originalPlans[0].day); // 8,原本的物件也被改到了
}

[...originalPlans] 建立了新陣列,但裡面的物件仍然共用,這叫淺拷貝(shallow copy)。在元件內寫 const copiedPlans = plans; 也只是在替同一個陣列取別名,連陣列都沒有複製。

如果只是顯示剩餘天數,像卡片一樣計算新數字就好,真的需要一份調整過的資料時,可以建立新陣列,並替要調整的項目建立新物件,例如:

{
  const originalPlans = [{ id: "my-react", day: 7 }];
  const nextPlans = originalPlans.map((plan) => // 在這裡透過map()建立了新陣列
    plan.id === "my-react" ? { ...plan, day: 8 } : plan,
  );

  console.log(originalPlans[0].day); // 7
  console.log(nextPlans[0].day); // 8
}

{ ...plan, day: 8 } 先把原有欄位放入新物件,再設定新的 day。這裡只調整最外層的數字欄位;如果裡面還有巢狀物件,要改哪一層,就還要處理那一層的複製 /images/emoticon/emoticon06.gif

淺拷貝可以看這篇:MDN:Spread syntax — Copying an array

後面還會學 state 和 context,也都是渲染時應當作唯讀的輸入。 (又挖坑)

好像有點太多,整理成表格比較清楚/images/emoticon/emoticon13.gif

名詞 觀念 範例
純函式 相同輸入得到相同結果,計算時不產生可觀察的副作用 傳入第 7 天、共 30 天,每次都算出剩餘 23 天
副作用(side effect) 計算之外,還改動了外部既有資料或觸發外部動作 渲染時執行 learningPlans[0].day += 1,讓原始進度跟著改變
props 唯讀 內層元件讀取外層元件提供的資料,不直接改寫它 卡片用 day 計算剩餘天數,要更新進度就從資料來源處理
區域修改 每次呼叫都新建、沒有與外部共用的資料,可以在本次計算中修改 topics 在元件內新建,再 push() 一個主題,不會累加到下一次
淺拷貝 複製陣列外層,裡面的物件仍可能共用 [...originalPlans] 之後改物件的 day,原始物件也可能被改到

所以看到 push() 或賦值時,可以看它改到誰的資料。
改到外面既有的共用資料,讓其他元件或下一次計算可能受到影響 => 副作用
修改的是本次呼叫新建的資料,沒有改到外部共用物件 => 區域修改。

檢查元件時,請常念 輸入相同,畫面內容會一樣嗎?計算過程有沒有影響外面的資料或系統?
講這麼多,接著就用這兩個問題,建立學習摘要元件來驗證吧。


建立學習摘要元件

因為 Welcome.tsx 有點長,就改用片段示意了,參考下面做法。

Welcome 函式外面加入 LearningSummary,讀取 plans,算出已完成的計畫數量:

function LearningSummary({ plans }: { plans: LearningPlan[] }) {
  const completedCount = plans.filter(
    (plan) => plan.day >= plan.totalDays,
  ).length;

  return <p>共 {plans.length} 份計畫,已完成 {completedCount} 份。</p>;
}

再到 Welcome() 回傳的 JSX,在原本的頁面標題下方加入:

<section>
  <h2>全部計畫的摘要,算兩次看看</h2>
  <LearningSummary plans={learningPlans} />
  <LearningSummary plans={learningPlans} /> {/* 驗證相同輸入的結果是一樣的 */}
</section>

測試執行

https://ithelp.ithome.com.tw/upload/images/20260921/20184332tp2gzQwxbH.png

錯誤修正練習

講這麼多純粹性,直接看錯誤示範會更明白為什麼要這麼做。

// 錯誤示範:這個變數被所有 LearningSummary 共用。
let completedCount = 0;

function LearningSummary({ plans }: { plans: LearningPlan[] }) {
  completedCount += plans.filter(
    (plan) => plan.day >= plan.totalDays,
  ).length;

  return <p>共 {plans.length} 份計畫,已完成 {completedCount} 份。</p>;
}

上面的程式,累加回外層的數字了,來看看實際會發生什麼事。
https://ithelp.ithome.com.tw/upload/images/20260921/20184332A8Hq0DXB6g.png

很明顯的看出,多出了幾個已完成的幽靈數量,而且型別檢查也會通過,因為數字加數字完全合法。

不同的開發工具更新、額外渲染,以及這份專案的伺服器渲染,都可能影響執行次數。伺服器和瀏覽器也有各自的變數,可能看到不同數字,或 hydration 警告(伺服器先產生的內容和瀏覽器接手時算出的內容不一致)。

修正方式,渲染時不要修改函式外的共用變數,每次根據 props 重新計算即可。

let completedCount = 0 移除,LearningSummary 改成:

function LearningSummary({ plans }: { plans: LearningPlan[] }) {
  const completedCount = plans.filter(
    (plan) => plan.day >= plan.totalDays,
  ).length;

  return <p>共 {plans.length} 份計畫,已完成 {completedCount} 份。</p>;
}

重新載入,確認兩行摘要都回到共 3 份計畫、已完成 1 份,就OK囉。


純粹性這件事情一開始很混亂,但懂了概念以及為什麼要這樣做之後,就可以明白一切都是為了保持畫面和資料的一致性。

本來還要做 StrictMode,但這樣太多了,會注意力渙散/images/emoticon/emoticon07.gif

明天接著繼續講 StrictMode、事件處理。

參考資料


上一篇
Day 6 : 一五一十,陣列與 key
下一篇
Day 8 : 一觸即發,用事件處理回應操作
系列文
React 入門到實作與除錯|30 天哩ㄟ刻9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言